[DE-8270] Model weights upload & download (SDK side) - #469
Conversation
Mirrors the REST surface shipped in scaleapi#149063 so users can attach a weights artifact to a model and fetch it back from Python. - `NucleusClient.upload_model_weights` / `download_model_weights` — the two methods the server PR's API-docs pages already document — plus `get_model_weights` and `delete_model_weights` for the remaining routes. - `Model.upload_weights()` / `download_weights()` / `weights()` / `delete_weights()` delegate to the client, matching how `Benchmark` does it. - New `ModelWeights` metadata type parsed from the weights DTO. The weights routes serialize camelCase both ways, unlike most of this SDK's endpoints, so the new payload keys are grouped and labelled in `constants.py`. - Transfers go straight to storage via presigned URLs and never through the API, so artifacts aren't subject to API request-size limits. Over 5 GB the server hands back multipart parts, which upload 4 at a time; `on_progress` reports `(bytes_transferred, total_bytes)`. - Size is checked against the server's 10 GB cap before presign, so an oversized file fails without a network round-trip. Two things worth knowing for review: part PUTs must be sent with *no* headers (they're signed without the Content-Type condition, so forwarding `requiredHeaders` makes S3 reject the signature), and download resolves the signed URL via `?json=1` rather than following the 302, so the API's auth headers are never sent to storage. 24 mock-based unit tests in `tests/test_model_weights.py`; version bumped to 0.19.1 (additive, per CLAUDE.md). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docstrings are what users read, so they shouldn't describe how the artifact gets stored or moved. Dropped the presign/multipart/direct-to-storage narration from every public docstring, the `ModelWeights` attribute docs, and the CHANGELOG, leaving what a caller actually needs: what the method does, who can call it, the size limit, and the arguments. Also made the transfer helpers private (`_presign_payload`, `_transfer_weights_to_storage`, `_stream_weights_to_file`, `_finalize_payload`) so the mechanics don't show up in the generated API docs at all, rather than only being reworded. Kept the two in-body comments that explain why part uploads send no headers and why the download URL is fetched as JSON — those aren't user-visible and each one guards a real footgun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
df6ede7 to
83c25f7
Compare
…progress Addresses two review comments: - transferred += len(chunk) ran unsynchronized across the part-upload pool, so concurrent workers could drop updates. The counter and the value handed to on_progress are now taken under a lock. - A single PUT reported nothing until it finished, then jumped to 100%. When a callback is supplied the body is wrapped so progress comes from the read side; the wrapper delegates everything but read(), so requests still sizes the body from fileno()/tell() and sends Content-Length as before.
Resolves two version-bump conflicts from #470 (v0.20.0): - pyproject.toml: 0.19.2/0.20.0 -> 0.20.1 - CHANGELOG.md: keep both sections, retitle the weights entry to 0.20.1 nucleus/__init__.py auto-merged cleanly (master touched create_benchmark, this branch adds the model-weights methods). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes from review: - Download streamed straight into the destination, so an interrupted transfer left a truncated artifact that looked complete. Stream to a sibling temp file and os.replace() on success; a failed re-download now also leaves any existing artifact intact. - The multipart progress counter was locked but on_progress was called after releasing, so two threads could compute 100 and 200 and then call in either order. Invoke the callback under the same lock. - Each in-flight part is read fully into memory, so peak usage was 4 * partSizeBytes with a server-chosen part size. Bound concurrency by a 512 MB budget via _part_upload_workers(), never below 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
👀 |
edwinpav
left a comment
There was a problem hiding this comment.
overall looks good. mostly nit comments - not too familiar with file reading and writing but the main comments are about error handling around when a non-expected amount of bytes are read/written. also for progress bar, didn't follow it super well but i've used tqdm package in python before which works well (i think it's already used within repo as well)
| parts = _transfer_weights_to_storage( | ||
| path, presign, total_bytes, on_progress | ||
| ) |
There was a problem hiding this comment.
should this be wrapped in a try/catch?
There was a problem hiding this comment.
id probably want to let this bubble up then catch and rethrow imo
There was a problem hiding this comment.
that works - so it's caught at some point?
There was a problem hiding this comment.
no like it should error out...if this is failing we should know quick iykwim
There was a problem hiding this comment.
unless you're concerned about security issues in the error stack
- upload_model_weights: expand `~` in path, hoist the resolved filename to a local, and validate the presign `uploadId` before starting the (up to 10 GB) transfer so a malformed presign fails fast instead of KeyError-ing at finalize. - download_model_weights: expand `~` in path; raise NotFoundError (the codebase idiom) instead of ValueError when a model has no weights artifact. - _stream_weights_to_file: verify received bytes match Content-Length before the atomic replace, so a short/interrupted stream can't promote a truncated file. - Model.upload_weights/download_weights: mirror the client's keyword-only params explicitly instead of **kwargs (better IDE/type-checker support); drop the now redundant quotes on the ModelWeights return annotations. - Tests: assert per-part bytes/offsets; add short-final-part, multipart-through- upload_model_weights (ordered finalize payload), concurrent part-failure, and raising-progress-callback coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rop unused _client Addresses the remaining review feedback: - Progress is now a tqdm bar shown by default (notebook-aware via the client's tqdm_bar), replacing the raw on_progress callback on the public API. Pass progress=False to silence it. upload_model_weights/download_model_weights and the Model wrappers take `progress: bool = True`. - S3 PUTs and the download GET now retry transient failures (network errors, 429, 5xx) up to 5 attempts with exponential backoff before giving up; 4xx is treated as terminal. Rewindable bodies (single PUT, download temp file) are re-seeked/truncated between attempts so a retry never sends a truncated body. The short-read download check now triggers a retry rather than a hard failure. - Removed the unused ModelWeights._client field and the client arg to from_json. - Tests: retry paths (transient-then-success, network error, exhaustion, no retry on 4xx, single-PUT re-seek, download retry) and tqdm on/off; autouse fixture no-ops backoff sleeps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| checksum_sha256: Optional SHA-256 of the artifact. | ||
| on_progress: Called with ``(bytes_uploaded, total_bytes)`` as the | ||
| upload proceeds. | ||
| progress: Whether to show a ``tqdm`` progress bar for the upload. |
There was a problem hiding this comment.
nit: any reason why we don't always show this progress bar? just curious
There was a problem hiding this comment.
might be loud for really big ones and users could just want a clean console...default is on, but maybe just in case you're wanting to pipe the stdout somewhere else
The tqdm adapter must match the ProgressCallback (transferred, total) signature, but it ignores total (the bar already knows it). Rename to _total and document why, dropping the pylint disable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
on_progress is internal-only (the public API takes progress: bool), and the sole consumer is the tqdm adapter, which ignores total since the bar already knows it. So ProgressCallback is now Callable[[int], None] — cumulative bytes only — instead of carrying a total that nothing reads. The download completeness check still uses Content-Length directly; it never went through the callback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
SDK side of model weights upload / download, mirroring the REST surface merged in scaleapi#149063.
The two primary methods are the ones the server PR's API-docs pages (
ApiDocsPage/models-python/{upload,download}-model-weights.md) already document, so the published docs and the SDK agree:Added
NucleusClient.upload_model_weights(model, path, *, content_type=None, original_filename=None, checksum_sha256=None, on_progress=None)— presign → PUT direct to storage → finalize. ReturnsModelWeights.NucleusClient.download_model_weights(model, path, *, on_progress=None)— resolves the signed URL and streams to disk (creating parent dirs). Returns the path written.get_model_weights(model)/delete_model_weights(model)for the remaining two routes.Model.upload_weights()/.download_weights()/.weights()/.delete_weights()— thin delegation to the client, matching howBenchmarkwraps its client methods.ModelWeightsmetadata type:present,status,size_bytes,original_filename,content_type,download_url.All model arguments accept either a
Modelor a bare model id (prj_*).Notes for review
requiredHeaders(which the single PUT does need) makes S3 reject the signature. There's a test pinning this in both directions — it's the easiest thing to get wrong here, and the frontend hook in 149063 has the same split.?json=1to fetch the signed URL rather than following the 302, so the API's auth headers are never sent to storage.constants.py.Tests / Version
tests/test_model_weights.py— 24 mock-based unit tests (no live API, no real S3): DTO parsing, payload builders, single vs. multipart transfer, header split, ETag/failure handling, progress callbacks, download streaming, all four client methods, and theModelwrappers.pylint nucleus10.00/10,mypy --ignore-missing-imports nucleusclean, ruff clean, black + isort clean, 61 mock-based tests passing across the eval/benchmark/leaderboard/weights suites.pyproject.toml→ 0.19.1 + CHANGELOG entry (patch bump: additive new methods, perCLAUDE.md).resolves https://linear.app/scale-epd/issue/DE-8270
🤖 Generated with Claude Code
Greptile Summary
This PR adds model weights upload and download to the Nucleus Python SDK, routing transfers directly to storage via presigned URLs so that multi-GB artifacts never pass through the Nucleus API. The implementation handles both single-PUT (< 5 GB) and S3 multipart (≥ 5 GB) paths with exponential-backoff retries, a threading-lock-protected progress counter for concurrent part uploads, atomic file replacement on download, and a
tqdmprogress bar that callers can suppress.NucleusClientgainsupload_model_weights,download_model_weights,get_model_weights, anddelete_model_weights;Modeladds thin delegation wrappers (upload_weights,download_weights,weights,delete_weights) matching the existingBenchmarkpattern.ModelWeightsis the new metadata dataclass;nucleus/constants.pyadds 14 camelCase wire-format keys (explicitly labelled as such since these routes do not snake-case their DTOs).Confidence Score: 5/5
Important Files Changed
Sequence Diagram
sequenceDiagram participant User participant NucleusClient participant NucleusAPI participant Storage Note over User,Storage: Upload flow User->>NucleusClient: upload_model_weights(model, path) NucleusClient->>NucleusClient: os.path.getsize(path) → check ≤ 10 GB NucleusClient->>NucleusAPI: "POST model/{id}/weights/presign" NucleusAPI-->>NucleusClient: "{uploadId, uploadUrl|parts, requiredHeaders}" alt "Single PUT (< 5 GB)" NucleusClient->>Storage: PUT uploadUrl (with requiredHeaders, _ProgressReader) Storage-->>NucleusClient: ETag else Multipart (≥ 5 GB) par 4 concurrent workers NucleusClient->>Storage: PUT parts[n].url (no headers) Storage-->>NucleusClient: ETag[n] end end NucleusClient->>NucleusAPI: "POST model/{id}/weights/finalize {uploadId, parts}" NucleusAPI-->>NucleusClient: ModelWeights JSON NucleusClient-->>User: ModelWeights Note over User,Storage: Download flow User->>NucleusClient: download_model_weights(model, path) NucleusClient->>NucleusAPI: "GET model/{id}/weights/download?json=1" NucleusAPI-->>NucleusClient: "{url: signed-GET-URL}" NucleusClient->>Storage: GET signed-URL (streaming → temp file) Storage-->>NucleusClient: body chunks NucleusClient->>NucleusClient: os.replace(partial_path, path) [atomic] NucleusClient-->>User: path writtenReviews (11): Last reviewed commit: "refactor(weights): drop the unused `tota..." | Re-trigger Greptile